Skip to content

Keep a non-enumerable key of a checked object non-enumerable in its record - #3235

Merged
ctate merged 2 commits into
mainfrom
feat/record-hidden-keys
Nov 7, 2026
Merged

ctate merged 2 commits into
mainfrom
feat/record-hidden-keys

Conversation

@cramforce

@cramforce cramforce commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

What

A record converted from an object keeps a property non-enumerable when the object held it that way.

interface Schema { kind: string; type: string }
const o: any = JSON.parse('{"type":"string"}');
Object.defineProperty(o, "kind", { value: "String", enumerable: false, writable: true, configurable: true });
const s = o as Schema;
JSON.stringify(s);   // Node and now scriptc: {"type":"string"}     before: {"type":"string","kind":"String"}
Object.keys(s);      // Node and now scriptc: ["type"]              before: ["type","kind"]

This is the shape schema libraries use for their bookkeeping keys (typebox hides ~kind and ~optional on every schema with Object.defineProperty(schema, key, { enumerable: false, writable: true, configurable: true, value })). Two conversions build a native record from such an object, and both were wrong in different ways:

  • The package boundary (a value a compiled package returns into a type its declarations give it) refused the object: TypeError: expected a plain object (this value has non-default property attributes). typebox compiled natively could not return its first schema.
  • Every other checked cast (value as T, a typed parameter of a JavaScript function, a JavaScript function's inferred return) built the record and dropped the attribute silently. Of 28 small probe programs over a record with one hidden declared key, 4 printed Node's output on main: Object.keys, Object.values, Object.entries, for…in, JSON.stringify, console.log, an untyped view of the record and the key's descriptor all listed the key as an ordinary one. 20 match now; the other 8 are the cases under "What it does not do" and two probes of other things.

With this change and #3208 (two lowerings its code also reaches), typebox 1.3.27 compiled natively reaches no deferred fence in a schema-building program and prints Node's output for schema construction, JSON.stringify, Value.Check, Value.Errors, Value.Default and Compile. A process that makes 660 schema calls at module level and serializes the schemas takes 9.9 ms (median of 21 runs, Intel Xeon 2.90 GHz), against 227 ms under Node and 177 ms with typebox in a Node island.

How

A native record has fields and no attributes. A checked record already has a place for what its layout cannot hold: when the source object has keys the type does not declare, or holds the declared ones in another order, the conversion keeps a view beside the record (scr_record_source_note), with one entry per own key of the source. Undeclared keys keep their whole descriptor there; declared keys hold a placeholder, because the record owns their values. Every reflective surface of a record that kept a view answers through it.

The placeholder of a declared key now keeps the one attribute the record cannot hold:

  • scr_record_source_note keeps a view when a declared key is a non-enumerable data property, and marks its placeholder (scr_record_source_hide);
  • scr_record_source_merge, which rebuilds the view from the record's values, carries the mark;
  • scr_jb_try_record_source, the record's JSON writer, skips a marked key;
  • scr_decl_adopt.c: the boundary's plain-data check admits a non-enumerable data property. A read-only or non-configurable property and an accessor keep their refusal, each with its reason in the message.

Nothing in the compiler changes. Object.keys, for…in, untyped reads and descriptors of such a record already went through the view, where the dynamic tier applies attributes.

A record that never had a hidden key is untouched: it has no entry in the table, as before, and reads and writes of fields never consult the table.

What it does not do

  • Read-only and non-configurable keys, and accessors. Still refused by name at the package boundary. At the other checked casts they are dropped as on main (a read-only declared key accepts a typed write; an accessor is read once). Those need a check where typed code writes the field and are not part of this change. Now stated on the limitations page.
  • Typed copies. Object spread, rest destructuring and structuredClone of such a record copy the declared fields, so the copy holds the key as an ordinary enumerable one, where Node leaves a non-enumerable key out of the copy (and the copy's static type says it has it). This extends the existing limitation that typed copies hold the declared fields, and is stated there. Object.assign from the record throws the checked conversion's TypeError when the result's type requires the key.
  • propertyIsEnumerable through the record's type answers true for the key.
  • Object.defineProperty through an untyped reference to a record typed code built is not addressed.

Zero cost: one-time rows

scr_json.c is linked by most programs, so its object moves in every executable that links one of the three functions. bin/validate --gate-corpus=all --gate-complete, ed4342b03 against f2b336d18:

VerdictPrograms
identical1,745
runtime-only (IR and LLVM identical)3,467
both-fail (Node island programs, which the gate builds without a libnode pack)102
differs0
  • IR and LLVM are identical for every program, and the built compiler is byte-identical to the base's (829 files), so nothing compiles differently.
  • Size. Each of the 3,467 executables grows by 168 to 624 bytes (median 240; +0.005 % to +0.14 %). The added code is three guarded branches and one 17-byte out-of-line helper over the existing entry lookup; a first version that inlined the attribute write cost 552 to 1,008 bytes per executable and was replaced.
  • Benchmarks. bench-runtime, 62 workloads, 16 runs over 4 layouts, baseline and candidate interleaved: geomean -0.4 %, 60 neutral, 2 faster (dynamic-methods -2.2 %, typed-array-scan-workers -3.8 %), 0 slower. The 23 workloads that use dynamic values are among the rows; the 35 fully typed ones are identical. Compile time: benchmarks -0.53 % [95 % CI -1.68 %, +0.93 %], within tolerance.
  • cli-config. The round on the first version reported it +4.4 % [+0.4 %, +11.7 %]. Alone, interleaved, 64 runs over 4 layouts (Intel Xeon 2.90 GHz): +0.6 % [-2.8 %, +2.9 %], neutral, with a spread between layouts of 5.3 % and 6.5 %. Instruction counts under cachegrind: 4,030,968 against 4,031,049; the 81 are in scr_str_new, scr_bytes_utf8_prefix and strcspn reading argv[0], whose length differed by one character between my two files. The three changed functions do not run in that workload. In this round it is neutral.
  • The gate's five "links ICU symbols" lines for intl-* programs are not from this change: it inspects a program's symbols only when its executable differs, and the baseline executables define the same symbols.

This is a one-time exception to "no runtime-only rows", as #3120 was: the alternatives were a second copy of the hidden field in the view, kept in step by compiled code, to get past the file-static view refresh without editing it, or a define-guarded second build of scr_json.c. #3199 (the unwind-padding fix) also changes every executable once; the two are best merged close together.

Textual conflicts with the other open changes to scr_json.c, test-merged against each head: none with #3097 (d6f0b39a4), #3137, opt/record-union-arm or fix/partial-assertion-declined; #3124 conflicts only in four scr_node_island* files, as it does against main without this branch.

Proof

  • tests/corpus/8854-record-hidden-key.ts: reads, Object.keys/values/entries, for…in, JSON.stringify (plain, nested, indented), in, Object.hasOwn, Object.getOwnPropertyNames, console.log, typed and untyped writes, untyped views and their descriptors, a cast back, conversions to other record types, records nested in records and arrays, making the key enumerable again, and a hidden key the type does not declare. Against Node byte for byte, plain and sanitized.
  • tests/fixtures/npm/cases/hidden-key-boundary: a package with the shapes schema libraries use (hidden keys set in a loop with run-time keys, a clone that copies descriptors with getOwnPropertyDescriptor, guards that read the hidden key from values typed code hands back). On main it dies at the boundary; here it matches Node.
  • tests/harness/decl-boundary.test.ts: a hidden key crosses the boundary; a read-only key and an accessor are refused with their reasons.
  • packages/runtime/src/scr_json.test.c: the note, the kept attribute and the merge.
  • Lanes in Sandboxes: plain 21,895 passed, sanitized 18,666 passed, 0 failed tests (97 jobs). One file, packages/runtime/test/regex.test.ts, hit its 60 s setup-hook limit in a loaded lane Sandbox and passes when run alone on this branch (38 s). The native bootstraps are part of those lanes and pass, plain and sanitized (the sanitized frontend and emitter rebuild the compiler).
  • Test262 (Proxy, Reflect, JSON, Function, Object chapters): 0 regressed, 0 gained. The suite's Object.defineProperty tests run on dynamic objects, which this change does not touch.
  • pnpm test:ts7 --baselines-only, pnpm lint, pnpm format:check and the docs gate (NEXT_DIST_DIR=.next-check pnpm check, 25 route tests) pass.

🤖 Generated with Claude Code

…ecord

A native record has fields and no attributes, so a checked conversion of
an object with a non-enumerable key lost the attribute. The package
boundary refused such an object ("this value has non-default property
attributes"), which stopped a natively compiled schema library at its
first schema: these libraries hide their bookkeeping keys with
Object.defineProperty(schema, key, { enumerable: false, writable: true,
configurable: true, value }). Every other checked cast built the record
and listed the key as an ordinary one in Object.keys, for...in,
JSON.stringify, console.log and untyped views of the record, where Node
leaves it out.

A checked record already keeps a view beside it for what its layout
cannot hold: the keys its type does not declare, with their descriptors,
and the source's key order. Declared keys hold placeholders there. The
placeholder now keeps the key's enumerability: the conversion keeps a
view when a declared key is a non-enumerable data property, the refresh
of the view from the record's values carries the mark, and the record's
JSON writer skips a marked key. Every other reflective surface of such a
record already answers through the view, where the dynamic tier applies
attributes. The boundary's plain-data check admits a non-enumerable data
property.

A read-only or non-configurable property and an accessor keep their
refusal at the boundary, now with their own reasons. Typed copies of
such a record (spread, rest, structuredClone) hold the key as an
ordinary field; the limitations page says so.

A record without a hidden key has no entry in the table and compiled
code does not change.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@vercel

vercel Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Nov 7, 2026 8:52am UTC

Comment thread packages/runtime/src/scr_decl_adopt.c Resolved
@cramforce

Copy link
Copy Markdown
Contributor Author

Why "Node island conformance 2/4" is red here, and where the fix is. Nothing is pushed: the entry is handed to the pi-2 train (trains/pi-2/INBOX/ci-real-failures.md; commit cb2e7a8929 on top of this head, bb5cf7430f).

npm/hidden-key-boundary > island:kindkit "does not match Node and known-failures.json does not list it": exit 1, Node 0: Uncaught Error: JSON.stringify of a runtime handle is not supported yet. The program is new in this PR. The lane builds it a second time with kindkit forced into a Node island; its first line passes a schema the package returned to JSON.stringify, and for an island value that is the gap main already lists for sixteen other programs. The default build matches Node; this is not a regression of the PR. The entry is written by the lane's update mode for that program alone (six lines); with it the lane's four shards pass on the merge with main 570f132b14, as do decl-boundary.test.ts (plain and sanitized), the npm cases hidden-key-boundary and zod3-boundary, and corpus 8854-record-hidden-key.

The other red jobs of the last run were main's: the stale conformance list (#3293) and packages/semver (#3331).

@ctate
ctate merged commit 8dcab7f into main Nov 7, 2026
75 of 80 checks passed
@ctate
ctate deleted the feat/record-hidden-keys branch November 7, 2026 19:24
cramforce added a commit that referenced this pull request Nov 7, 2026
Main since the last merge: #3294, #3269, #3253, #3235. No conflict.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce added a commit that referenced this pull request Nov 7, 2026
…3016, #3156, #3018, #2952) into train/pi-1

Conflict: expressions/narrowing.ts, imports (#3167's native-proxy import beside main's wider asserted-receiver import and #3156's erased-delete import: all kept).

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce added a commit that referenced this pull request Nov 7, 2026
Main gained #3364 (its build fix, the commit this branch had merged from the
fix branch; identical content), #3294, #3269, #3253 and #3235. One conflict,
packages/compiler/src/frontend/canon-map.ts: #3290's collapse of an array of
checked-dynamic elements first, then main's storage rule for array elements
(#3269); both kept.

Co-Authored-By: Claude Code <noreply@anthropic.com>
espetro pushed a commit to espetro/scriptc that referenced this pull request Nov 8, 2026
… main

Both lists were stale on main since vercel-labs#3052 (JSON.stringify walks island objects
and arrays; an object or array an island made converts into a native slot):
22 conformance entries and 111 of 239 matrix cells failed on plain main.

Regenerated on main 8dcab7f by each list's update mode, then both run in
check mode against the result (conformance 424 passed, 70 skipped; matrix 239
of 239).

Conformance, 84 entries to 68: 17 programs match Node now, 5 fail differently
(npm/zod4-record with zod in an island now prints a wrong line silently),
1 is new (npm/hidden-key-boundary, added by vercel-labs#3235 without its island way).
Matrix, per observation: 1,055 moved to a match, 326 moved to 'wrong' (286 of
them matched Node before). The PR lists them.

Co-Authored-By: Claude Code <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — bb5cf743 Deployed Nov 7, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants